/*
 * Copyright 2014 Splunk, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"): you may
 * not use this file except in compliance with the License. You may obtain
 * a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */
package com.splunk;

import com.google.gson.*;

import java.util.*;

/**
 * PivotSpecification represents a pivot to be done on a particular data model object. The user creates a
 * PivotSpecification on some data model object, adds filters, row splits, column splits, and cell values,
 * then calls the pivot method to query splunkd and get a set of SPL queries corresponding to this specification.
 */
public class PivotSpecification {
    private static GsonBuilder gson = new GsonBuilder();

    private DataModelObject dataModelObject;
    private String accelerationNamespace = null;

    private List<PivotColumnSplit> columns = new ArrayList<PivotColumnSplit>();
    private List<PivotFilter> filters = new ArrayList<PivotFilter>();
    private List<PivotCellValue> cells = new ArrayList<PivotCellValue>();
    private List<PivotRowSplit> rows = new ArrayList<PivotRowSplit>();

    PivotSpecification(DataModelObject dataModelObject) {
        this.dataModelObject = dataModelObject;
        if (dataModelObject.getDataModel().isAccelerated()) {
            this.accelerationNamespace = dataModelObject.getDataModel().getName();
        } else {
            this.accelerationNamespace = null;
        }
    }

    /**
     * Set the namespace to use for this acceleration, usually the name of a data model. A value of null will set no
     * namespace for acceleration.
     *
     * @param namespace a string specifying a namespace.
     */
    public PivotSpecification setAccelerationNamespace(String namespace) {
        this.accelerationNamespace = namespace;
        return this;
    }

    /**
     * Set a job with a query ending in tscollect, usually generated by createLocalAccelerationJob on a
     * DataModelObject instance, as the acceleration cache for this pivot.
     *
     * @param sid the SID of a job.
     */
    public PivotSpecification setAccelerationJob(String sid) {
        if (sid == null) {
            throw new IllegalArgumentException("Sid to use for acceleration must not be null.");
        } else {
            this.accelerationNamespace = "sid=" + sid;
        }
        return this;
    }

    /**
     * Set a job with a query ending in tscollect, usually generated by createLocalAccelerationJob on a
     * DataModelObject instance, as the acceleration cache for this pivot.
     *
     * @param job a Job object.
     */
    public PivotSpecification setAccelerationJob(Job job) {
        setAccelerationJob(job.getSid());
        return this;
    }

    /**
     * @return the acceleration namespace to use in this pivot.
     */
    public String getAccelerationNamespace() {
        return this.accelerationNamespace;
    }

    private void assertCorrectlyTypedField(String fieldName, FieldType[] acceptableTypes) {
        DataModelField field = this.dataModelObject.getField(fieldName);
        if (field == null) {
            throw new IllegalArgumentException("No such field named " + fieldName);
        } else if (!Arrays.asList(acceptableTypes).contains(field.getType())) {
            StringBuilder errorMessage = new StringBuilder();
            errorMessage.append("Expected a field of one of the following types: ");
            boolean first = true;
            for (FieldType t : acceptableTypes) {
                if (!first) errorMessage.append(", ");
                errorMessage.append(t.toString());
                first = false;
            }
            errorMessage.append("; found type " + field.getType().toString());
            throw new IllegalArgumentException(errorMessage.toString());
        }
    }

    private void assertCorrectlyTypedField(String field, FieldType acceptableType) {
        assertCorrectlyTypedField(field, new FieldType[] { acceptableType });
    }

    /**
     * Add a filter on a boolean valued field. The filter will be a constraint of the form
     *
     *     field `comparison` compareTo
     *
     * for example
     *
     *     is_remote = false
     *
     * @param field the name of the field
     * @param comparison a comparison operator for the filter
     * @param compareTo the value to compare the field to
     * @return the PivotSpecification you are operating on.
     */
    public PivotSpecification addFilter(String field, BooleanComparison comparison, boolean compareTo) {
        assertCorrectlyTypedField(field, FieldType.BOOLEAN);

        BooleanPivotFilter filter = new BooleanPivotFilter(this.dataModelObject, field, comparison, compareTo);
        filters.add(filter);

        return this;
    }

    /**
     * Add a filter on a string valued field. The filter will be a constraint of the form
     *
     *     field `comparison` compareTo
     *
     * for example
     *
     *     host startswith 'boris'
     *
     * @param field the name of the field
     * @param comparison a comparison operator for the filter
     * @param comparisonValue the value to compare the field to
     * @return the PivotSpecification you are operating on.
     */
    public PivotSpecification addFilter(String field, StringComparison comparison, String comparisonValue) {
        assertCorrectlyTypedField(field, FieldType.STRING);

        StringPivotFilter filter = new StringPivotFilter(this.dataModelObject, field, comparison, comparisonValue);
        filters.add(filter);

        return this;
    }

    /**
     * Add a filter on an IPv4 valued field. The filter will be a constraint of the form
     *
     *     field `comparison` compareTo
     *
     * for example
     *
     *     hostip = 192.168.100.12
     *
     * @param field the name of the field
     * @param comparison a comparison operator for the filter
     * @param comparisonValue the value to compare the field to
     * @return the PivotSpecification you are operating on.
     */
    public PivotSpecification addFilter(String field, IPv4Comparison comparison, String comparisonValue) {
        assertCorrectlyTypedField(field, FieldType.IPV4);

        IPv4PivotFilter filter = new IPv4PivotFilter(this.dataModelObject, field, comparison, comparisonValue);
        filters.add(filter);

        return this;
    }

    /**
     * Add a filter on a numeric field. The filter will be a constraint of the form
     *
     *     field `comparison` compareTo
     *
     * for example
     *
     *     {@code height > 6}
     *
     * @param field the name of the field
     * @param comparison a comparison operator for the filter
     * @param comparisonValue the value to compare the field to
     * @return the PivotSpecification you are operating on.
     */
    public PivotSpecification addFilter(String field, NumberComparison comparison, double comparisonValue) {
        assertCorrectlyTypedField(field, FieldType.NUMBER);

        NumberPivotFilter filter = new NumberPivotFilter(this.dataModelObject, field, comparison, comparisonValue);
        filters.add(filter);

        return this;
    }

    /**
     * Add a filter that limits the number of values of an aggregated field that will be allowed
     * into the pivot.
     *
     * @param field the name of a field
     * @param sortAttribute field to aggregate for limiting
     * @param sortDirection whether to take the lowest or highest values of the aggregated field
     * @param limit how many values of the aggregated field to take
     * @param statsFunction the function to use for aggregation
     * @return The PivotSpecification you are modifying.
     */
    public PivotSpecification addFilter(String field, String sortAttribute,
                                        SortDirection sortDirection, int limit, StatsFunction statsFunction) {
        if (!dataModelObject.containsField(field)) {
            throw new IllegalArgumentException("No such field " + sortAttribute);
        }
        assertCorrectlyTypedField(
                sortAttribute,
                new FieldType[] { FieldType.STRING, FieldType.NUMBER, FieldType.OBJECTCOUNT }
        );

        LimitPivotFilter filter = new LimitPivotFilter(this.dataModelObject, field, sortAttribute,
                sortDirection, limit, statsFunction);
        filters.add(filter);

        return this;
    }

    /**
     * Add a row split on a numeric or string valued field, splitting on each distinct value of the field.
     *
     * @param field name of the field to split on
     * @param  label a human readable name for this set of rows
     * @return The PivotSpecification you are modifying.
     */
    public PivotSpecification addRowSplit(String field, String label) {
        assertCorrectlyTypedField(field, new FieldType[] { FieldType.NUMBER, FieldType.STRING });

        FieldType t = this.dataModelObject.getField(field).getType();
        if (t == FieldType.NUMBER) {
            rows.add(new NumberPivotRowSplit(this.dataModelObject, field, label));
        } else if (t == FieldType.STRING) {
            rows.add(new StringPivotRowSplit(this.dataModelObject, field, label));
        } else {
            throw new IllegalArgumentException("Field not of type number or string despite precondition asserting so.");
        }

        return this;
    }

    /**
     * Add a row split on a numeric field, splitting into numeric ranges.
     * 
     * This split generates bins with edges equivalent to the
     * classic loop {@code 'for i in <start> to <end> by <step>' } but with a maximum
     * number of bins {@code <limit> }. This dispatches to the stats and xyseries search commands.
     * See their documentation for more details.
     * 
     *
     * @param field The field to split on
     * @param label a human readable name for this set of rows
     * @param start the value of the start of the first range, or null to take the lowest value in the events.
     * @param end the value for the end of the last range, or null to take the highest value in the events.
     * @param step the width of each range, or null to have Splunk calculate it.
     * @param limit the maximum number of ranges to split into, or null for no limit.
     * @return The PivotSpecification you are modifying.
     */
    public PivotSpecification addRowSplit(String field, String label, Integer start, Integer end,
                                          Integer step, Integer limit) {
        assertCorrectlyTypedField(field, FieldType.NUMBER);

        PivotRowSplit split = new RangePivotRowSplit(this.dataModelObject, field, label, start, end, step, limit);
        rows.add(split);

        return this;
    }

    /**
     * Add a row split on a boolean valued field.
     *
     * @param trueDisplayValue the string to display in the true valued row label.
     * @param falseDisplayValue the string to display in the false valued row label;
     * @return The PivotSpecification you are modifying.
     */
    public PivotSpecification addRowSplit(String field, String label,
                                          String trueDisplayValue, String falseDisplayValue) {
        assertCorrectlyTypedField(field, FieldType.BOOLEAN);

        PivotRowSplit split = new BooleanPivotRowSplit(this.dataModelObject, field, label,
                trueDisplayValue, falseDisplayValue);
        rows.add(split);

        return this;
    }

    /**
     * Add a row split on a timestamp  valued field, binned by the specified bucket size.
     *
     * @param field the name of the field to split on.
     * @param label a human readable name for this set of rows
     * @param binning the size of bins to use
     * @return The PivotSpecification you are modifying.
     */
    public PivotSpecification addRowSplit(String field, String label, TimestampBinning binning) {
        assertCorrectlyTypedField(field, FieldType.TIMESTAMP);

        PivotRowSplit split = new TimestampPivotRowSplit(this.dataModelObject, field, label, binning);
        rows.add(split);

        return this;
    }

    /**
     * Add a column split on a string or number valued field, producing a column for
     * each distinct value of the field.
     *
     * @param field the field to split on.
     * @return The PivotSpecification you are modifying.
     */
    public PivotSpecification addColumnSplit(String field) {
        assertCorrectlyTypedField(field, new FieldType[] { FieldType.NUMBER, FieldType.STRING });

        FieldType t = this.dataModelObject.getField(field).getType();

        if (t == FieldType.NUMBER) {
            columns.add(new NumericPivotColumnSplit(this.dataModelObject, field));
        } else if (t == FieldType.STRING) {
            columns.add(new StringPivotColumnSplit(this.dataModelObject, field));
        }

        return this;
    }

    /**
     * Add a column split on a numeric field, splitting the values into ranges.
     *
     * @param field the field to split on.
     * @param start the value of the start of the first range, or null to take the lowest value in the events.
     * @param end the value for the end of the last range, or null to take the highest value in the events.
     * @param step the width of each range, or null to have Splunk calculate it.
     * @param limit the maximum number of ranges to split into, or null for no limit.
     * @return The PivotSpecification you are modifying.
     */
    public PivotSpecification addColumnSplit(String field, Integer start, Integer end, Integer step, Integer limit) {
        assertCorrectlyTypedField(field, FieldType.NUMBER);

        PivotColumnSplit split = new RangePivotColumnSplit(this.dataModelObject, field, start, end, step, limit);

        columns.add(split);
        return this;
    }

    /**
     * Add a column split on a boolean valued field.
     *
     * @param field the field to split on.
     * @param trueDisplayValue the string to display in the true valued column label.
     * @param falseDisplayValue the string to display in the false valued column label.
     * @return the PivotSpecification you are working on.
     */
    public PivotSpecification addColumnSplit(String field, String trueDisplayValue, String falseDisplayValue) {
        assertCorrectlyTypedField(field, FieldType.BOOLEAN);

        PivotColumnSplit split = new BooleanPivotColumnSplit(this.dataModelObject, field,
                trueDisplayValue, falseDisplayValue);

        columns.add(split);
        return this;
    }

    /**
     * Add a column split on a timestamp valued field.
     *
     * @param field the field to split on.
     * @param binning what time periods to use for binning valued of the field.
     * @return the PivotSpecification you are working on.
     */
    public PivotSpecification addColumnSplit(String field, TimestampBinning binning) {
        assertCorrectlyTypedField(field, FieldType.TIMESTAMP);

        PivotColumnSplit split = new TimestampPivotColumnSplit(this.dataModelObject, field, binning);

        columns.add(split);
        return this;
    }

    /**
     * Add an aggregate to each cell of the pivot.
     *
     * @param field the field to aggregate.
     * @param label a human readable name for this aggregate.
     * @param statsFunction the function to use for aggregation.
     * @return the PivotSpecification you are working on.
     */
    public PivotSpecification addCellValue(String field, String label, StatsFunction statsFunction) {
        cells.add(new PivotCellValue(this.dataModelObject, field, label, statsFunction));

        return this;
    }

    /**
     * @return a JSON serialization of this object.
     */
    JsonObject toJson() {
        JsonObject root = new JsonObject();

        root.addProperty("dataModel", this.dataModelObject.getDataModel().getName());
        root.addProperty("baseClass", this.dataModelObject.getName());

        JsonArray filterArray = new JsonArray();
        for (PivotFilter p : filters) { filterArray.add(p.toJson()); }
        root.add("filters", filterArray);

        JsonArray rowsplitArray = new JsonArray();
        for (PivotRowSplit p : rows) { rowsplitArray.add(p.toJson()); }
        root.add("rows", rowsplitArray);

        JsonArray cellvalueArray = new JsonArray();
        for (PivotCellValue p : cells) { cellvalueArray.add(p.toJson()); }
        root.add("cells", cellvalueArray);

        JsonArray columnsplitArray = new JsonArray();
        for (PivotColumnSplit p : columns) { columnsplitArray.add(p.toJson()); }
        root.add("columns", columnsplitArray);

        return root;
    }

    /**
     * @return a collection of all the filters added to this PivotSpecification.
     */
    public Collection<PivotFilter> getFilters() {
        return Collections.unmodifiableCollection(this.filters);
    }

    /**
     * @return a collection of all the row splits added to this PivotSpecification.
     */
    public Collection<PivotRowSplit> getRowSplits() {
        return Collections.unmodifiableCollection(this.rows);
    }

    /**
     * @return a collection of all the column splits added to this PivotSpecification.
     */
    public Collection<PivotColumnSplit> getColumnSplits() {
        return Collections.unmodifiableCollection(this.columns);
    }

    /**
     * @return a collection of all the cell values added to this PivotSpecification.
     */
    public Collection<PivotCellValue> getCellValues() {
        return Collections.unmodifiableCollection(this.cells);
    }

    /**
     * Query Splunk for SPL queries corresponding to this pivot.
     *
     * @return a Pivot object encapsulating the returned queries.
     */
    public Pivot pivot() {
        Service service = this.dataModelObject.getDataModel().getService();

        Args args = new Args();
        args.put("pivot_json", toJson());
        if (this.accelerationNamespace != null) {
            args.put("namespace", this.accelerationNamespace);
        }

        ResponseMessage response = service.get(
            "datamodel/pivot/" + this.dataModelObject.getDataModel().getName(),
            args
        );

        if (response.getStatus() != 200) {
            throw HttpException.create(response);
        } else {
            return Pivot.parseStream(service, response.getContent());
        }
    }
}
